Skip to content

feat(observability): export logs, metrics, and traces over OTLP - #174

Open
Bnjoroge1 wants to merge 11 commits into
pr/4-metricsfrom
pr/5-otlp-export
Open

feat(observability): export logs, metrics, and traces over OTLP#174
Bnjoroge1 wants to merge 11 commits into
pr/4-metricsfrom
pr/5-otlp-export

Conversation

@Bnjoroge1

@Bnjoroge1 Bnjoroge1 commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Bounded OTLP/HTTP JSON exporter (2048-capacity channel, 5s flush, fail-open, one worker for all three signals) with per-signal endpoint/header resolution — signal-specific URLs used as-is, generic base suffixed — partialSuccess accounting, per-record timestamps, and a real 2s shutdown drain. W3C trace context adoption with all-zero-id rejection; cumulative metrics with process-start startTimeUnixNano; disjoint histogram buckets that conserve count; jobs carry an enqueue timestamp for true queue latency. Pinned single-node OpenObserve reference profile (loopback, required admin password).

Part of a stacked series (merge bottom-up):

  1. log hardening
  2. foundation crate
  3. status endpoints
  4. metrics
  5. this PR (OTLP export)
  6. review-fix sweep

Summary by cubic

Exports logs, metrics, and traces over OTLP/HTTP JSON via a single bounded background worker. Previously only logs reached a backend, metrics were Prometheus-pull only, and traces were dropped; now all three signals export to configured OTLP endpoints with bounded labels and a graceful shutdown flush.

  • Per-signal export: signal-specific endpoints/headers are used as-is; the generic base appends /v1/{logs|traces|metrics}. The worker batches (capacity 2048, batch 256, 5s flush), fails open, accounts partialSuccess, timestamps each record, and drains for 2s on shutdown.
  • Tracing: adopts W3C Trace Context (rejects all-zero IDs), suppresses health and metrics probes, marks errors only on 5xx, and uses route templates/surfaces (logs carry traceId/spanId fields).
  • Metrics: exports cumulative counters/gauges and explicit-bucket histograms with a correct +Inf bucket; each point includes startTimeUnixNano from process start.
  • Label hardening and fixes: route labels come from a bounded template table; HTTP methods are allowlisted with an other bucket; the active-requests gauge no longer keys on status; Prometheus exposition escapes label values.
  • Events and reasons: terminal status exports as job.status.terminal (was job.completed); termination reason is now a bounded code with full prose on reason.detail (adds no_platform_runner alongside no_runner).
  • Versioning and telemetry data: service.version now reflects the host binary via with_service_version; VM host usage fields serialize as absent when unmeasured.
  • Operational aid: an optional, digest-pinned OpenObserve compose profile runs loopback-only and requires an explicit admin password.

Rollout

  • To enable export, set OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_{LOGS,TRACES,METRICS}_ENDPOINT and optional OTEL_EXPORTER_OTLP_{…}_HEADERS. Use full URLs for signal-specific variables; the generic base is auto-suffixed.
  • Update dashboards/alerts that reference: event job.completedjob.status.terminal; termination reason values (now bounded codes); HTTP method may collapse to other; route labels use normalized templates.
  • No data migration is required. Persisted jobs default enqueued_at_unix_nanos to 0 and are skipped for latency until new jobs populate the field.

Written for commit 56ca02b. Summary will update on new commits.

Review in cubic

Note

Export logs, metrics, and traces over OTLP with background batching worker

  • Adds a complete OTLP/HTTP exporter in export.rs with bounded non-blocking enqueue, 5s flush interval, batch size of 256, and health counters for sent/failed/dropped records
  • Splits ObservabilityConfig into per-signal endpoints and headers (otel_logs_endpoint, otel_traces_endpoint, otel_metrics_endpoint); a generic OTEL_EXPORTER_OTLP_ENDPOINT gets /v1/<signal> appended as fallback
  • Adds collect methods to HttpMetrics, StoreMetrics, LifecycleMetrics, and MetricsRegistry to snapshot metrics into OTLP MetricFamily structures with cumulative temporality
  • Reworks the HTTP metrics middleware in http_metrics.rs to adopt inbound W3C traceparent for non-public surfaces, export server spans with bounded attributes, and use an ActiveGuard that reliably decrements the active-requests gauge on all exit paths
  • Adds bounded reason/conclusion classification in AppState::emit and stamps QueuedJob with enqueued_at_unix_nanos for queue latency measurement
  • Behavioral Change: HttpMetrics active gauge is now keyed by ActiveLabels (no status_class), preventing duplicate Prometheus series for the same printed label set; normalize_route no longer treats paths containing : as pre-normalized templates, so such paths now map to known templates by prefix rules or fall back to /unknown
📊 Macroscope summarized 56ca02b. 13 files reviewed, 24 issues evaluated, 10 issues filtered, 12 comments posted

🗂️ Filtered Issues

crates/preloop-observability/src/lib.rs — 5 comments posted, 11 evaluated, 6 filtered
  • line 202: sanitized_endpoint now inspects only otel_logs_endpoint. With a traces-only or metrics-only configuration, Debug reports otel_endpoint: None even though otlp_enabled is true; with different per-signal URLs it reports only the logs destination. This makes the configuration diagnostics incorrect for the newly supported per-signal setup. [ Out of scope (post-validation triage) ]
  • line 425: The single exporter field represents “any OTLP signal configured,” but downstream tracing_enabled/export_span treat its presence as “traces configured.” With a logs-only or metrics-only endpoint, request spans are still generated and enqueued; flush_spans returns without clearing the buffer when targets.traces is None, so the worker's span vector grows without bound and can eventually OOM the process. Track per-signal enablement or discard buffers for absent targets. [ Cross-file consolidated ]
  • line 547: export_log_in_span enqueues logs whenever any exporter exists, even when only metrics or traces are configured. In that configuration flush_logs returns immediately for a missing logs target without clearing its buffer, so every emitted log remains in the worker's logs vector indefinitely; after 256 records every further record also triggers a futile flush. A metrics-only/traces-only deployment that emits logs therefore grows memory without bound and can eventually be OOM-killed. Gate this on the logs target (or make missing-target flushes discard the buffer). [ Cross-file consolidated ]
  • line 547: export_log_in_span gates enqueueing only on the shared Exporter existing, not on a logs target being configured (and export_span/tracing_enabled do the same for traces). With a metrics-only or traces-only configuration, normal event logs are therefore queued and appended to the worker's logs buffer, while flush_logs immediately returns when its target is None and never clears that buffer. Sustained events grow the vector without bound and can eventually OOM the process; a logs-only configuration similarly accumulates every HTTP span indefinitely. [ Cross-file consolidated ]
  • line 571: tracing_enabled returns true whenever any exporter exists, including logs-only or metrics-only configurations. HTTP middleware therefore enqueues a SpanRecord for every non-public request even when no traces endpoint exists; flush_spans returns early without clearing its buffer when the target is None, so that buffer grows without bound and can eventually exhaust process memory. This should specifically test whether a traces target is configured (or spans without a target must be discarded). [ Out of scope ]
  • line 654: shutdown signals the worker to drain, but the worker's shutdown branch only flushes its already-accumulated logs/spans vectors; it never drains records still waiting in the mpsc receiver. Thus a clean exit with queued telemetry immediately drops those records, defeating the new shutdown-drain behavior. The shutdown path must consume queued Items (up to the time/bound policy) before its final flush. [ Out of scope ]
crates/preloop-runner-server/src/http_metrics.rs — 0 comments posted, 2 evaluated, 2 filtered
  • line 77: traced uses Observability::tracing_enabled(), which is true whenever the shared exporter exists, even if only logs or metrics have an endpoint. In that valid per-signal configuration this middleware enqueues a span for every non-public request, while flush_spans returns immediately when targets.traces is None without clearing its buffer. The worker therefore retains an ever-growing Vec<SpanRecord> and can eventually exhaust memory. Trace generation must be gated on a configured traces target (or unsupported-signal buffers must be discarded). [ Cross-file consolidated ]
  • line 79: http_metrics_middleware now adopts SpanContext::from_traceparent for every traced request, but that parser only checks that there are four fields and validates the two IDs; it never validates the trace-flags field and accepts arbitrary version values. Thus malformed headers such as 00-<valid trace id>-<valid parent id>-zz are propagated as the caller's trace instead of starting a new root as documented, producing invalid/misassociated telemetry. [ Out of scope ]
crates/preloop-runner-server/src/state.rs — 0 comments posted, 1 evaluated, 1 filtered
  • line 610: bounded_termination_reason checks the broad contains("runner is registered with this server") rule before the starvation prefix. Because starvation reasons interpolate user-controlled runs-on labels, a label containing that phrase makes a real starvation event classify as no_platform_runner instead of no_runner, corrupting the termination metric. Match the stable starvation prefix first or constrain the platform sentence structurally. [ Out of scope (post-validation triage) ]
crates/preloop-runner/src/main.rs — 0 comments posted, 1 evaluated, 1 filtered
  • line 25: main keeps observability_runtime in an underscore binding but never calls ObservabilityRuntime::shutdown().await. When an OTLP endpoint is configured, normal command completion immediately tears down the Tokio runtime while the export worker may still hold up to a flush window of records, so runner telemetry is lost instead of receiving the implemented bounded drain. The other binaries explicitly invoke shutdown() before returning. [ Out of scope ]

Bnjoroge1 and others added 11 commits August 20, 2026 21:37
Entire-Checkpoint: 01M0GMGVPS35504XE9KQKPGV14
…version

Three defects surfaced by reading an exported record.

The reason label was wrong. The control plane's `reason` is not a code:
the starvation sweep builds a prose sentence that interpolates the job's
`runs-on` labels. Passing it through would explode metric cardinality and
export workflow content, so the previous code bounded it — but it bounded
every value, including the common `reason: None`, to "unknown". That
labelled "no reason supplied" and "unrecognized string" identically and
made a legitimately failed job unexplainable. Now `None` is `unspecified`,
exact codes pass through, and prose is classified on its stable leading
phrase, so the starvation sentence becomes `no_runner`. The full message
stays on the log record; only the metric dimension is bounded.

The event name conflated two records. A terminal `JobStatus` is a status
transition, not the separate `JobCompleted` event, but both exported
`body: "job.completed"`. The transition is now `job.status.terminal`.

`service.version` reported the observability crate's version, which is
meaningless to an operator. `ObservabilityConfig::with_service_version`
now takes the host binary's version and all three binaries pass it.

Tests: five cases for the classifier, including a hostile interpolated
`runs-on` and a 1,000-string drive asserting the label set stays at two.

Entire-Checkpoint: 01M0GP28K4J52DGX9CMDJVCYF5
Logs were the only signal reaching a backend; metrics were Prometheus-pull
only and traces did not exist at all — the HTTP middleware built a span and
dropped it.

Metrics. `MetricsRegistry::collect` snapshots every instrument as
OTLP-ready families, so export scrapes the same instruments `/metrics`
renders rather than maintaining a second set. Counters become cumulative
monotonic sums, gauges become gauges, and the internal histogram becomes an
explicit-bucket histogram with the implicit `+Inf` bucket OTLP requires.
Every cumulative point carries the process start as `startTimeUnixNano`,
without which a backend reads a restart as a counter reset.

Traces. Add real W3C Trace Context: an inbound `traceparent` is adopted so a
caller's trace continues through the control plane, a malformed one starts a
new root rather than failing the request, and all-zero ids are rejected per
the spec. Spans carry the matched route template and finite surface, never
the raw URI. Only 5xx sets `Error` — marking 4xx would make every
unauthenticated probe look like an outage. Health and metrics probes are
suppressed from trace export; they would swamp the store and explain
nothing. Log records now carry `traceId`/`spanId` as OTLP fields, not
attributes, so a backend can pivot log to trace.

One worker drains logs and spans from a shared bounded queue and scrapes
metrics on the same tick, so all three share one client, one batching
cadence, and one fail-open path.

Verified against a pinned single-node OpenObserve: traces, logs and metrics
streams all populated; an injected traceparent arrived as
trace_id=4bf92f3577b34da6a3ce929d0e0e4736 with a fresh span id; histogram
points carry AGGREGATION_TEMPORALITY_CUMULATIVE with bounded attributes
(http_route, preloop_surface) and service_version 0.2.0; public probes
absent from spans. 24 crate tests including traceparent adoption, malformed
rejection, id uniqueness, OTLP shapes, and the +Inf bucket invariant.

Entire-Checkpoint: 01M0GPJ7JVYWNJMPJYVE5QW4P6
…ost failures

Load testing exposed both halves of this. A sustained run produced 21
`reason="unrecognized"` job completions with no way to find out what they
were: the previous change claimed the full message stayed on the log record,
but only the bounded code was ever attached, so the prose was unrecoverable.
Attach it as `reason.detail`. Logs are not a label space, and without it an
`unrecognized` classification is a dead end.

With the detail visible the path was obvious — a second never-claimable
sentence the classifier did not match:

  no windows runner is registered with this server, so `runs-on:
  windows-latest` cannot be scheduled

built at runtime_scheduling.rs with the platform interpolated. It is a
distinct condition from the starvation sweep and gets its own code rather
than folding into `no_runner`: the sweep means "no matching runner appeared
within the grace window", which more capacity fixes, while this means the
server has no runner of that platform class at all and never will until one
is registered. Matching is on the invariant phrase, so any interpolated
platform classifies.

After the fix a mixed load of 40 workflow submits and 640 reads produced
only `no_runner` (56) and `no_platform_runner` (8), with zero
`unrecognized`, four route templates and two surfaces.

Entire-Checkpoint: 01M0GQDWZHCJVWQ5TQB0XEETTB
…auge

Three review findings, all reproduced live against a running server:

1. Unbounded route labels. `normalize_route` short-circuited on
   `path.contains(':')`, returning the raw path as a label value. A colon is
   legal inside a path segment, so an unauthenticated 404 like `/evil:1234`
   created one permanent series per distinct URI — remote memory exhaustion.
   It now matches only exact entries in the template table, and the
   parameterized-template branch requires a non-empty child segment so a bare
   collection path (`/api/v1/runs`) resolves to its own template instead of
   the single-item one.

2. Unbounded method labels. `req.method().to_string()` copied extension
   methods (`X-0001`, …) verbatim into the same map. Methods are now
   allowlisted to the standard set with an `other` bucket.

3. Leaking active-requests gauge. The gauge was keyed on the full label set
   including `status_class`, which the middleware set to a "2xx" placeholder
   before the handler ran and overwrote with the real class after — so every
   non-2xx request incremented one series and decremented another. The
   `2xx` series grew without bound (160 phantom in-flight after a 4xx
   storm) and the 4xx/5xx series went negative. The gauge is now keyed on a
   status-free `ActiveLabels`, and the decrement runs from a drop guard so
   cancellation, panics, and client disconnects on a long poll release the
   slot too.

4. Malformed OTLP histograms. Buckets were stored cumulative (Prometheus
   `le` semantics) and emitted as-if-disjoint; every observation was counted
   once per bucket and the total exceeded `count`. `otlp_bucket_counts` now
   differences adjacent cumulative values and uses `count - last` for +Inf,
   so the counts conserve the total. Label values are also escaped in the
   Prometheus exposition as defense in depth.

Verification: live server after the fix shows 0 phantom active requests, 0
raw evil-route series, methods collapsed to `other`, and every histogram's
+Inf bucket equals its declared count. Tests cover the colon escape, the
collection-route template match, label escaping, gauge increment/decrement
idempotence, and the cumulative-to-disjoint conversion.

Entire-Checkpoint: 01M0GYRVWN21MA39T53T6NB03R
…down flush

Three exporter defects from review, all confirmed in code:

1. Signal-specific endpoints were misrouted. A single endpoint was selected
   with a fallback chain and `/v1/logs`, `/v1/traces`, `/v1/metrics` were
   appended unconditionally, so `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=
   http://collector/v1/traces` produced `/v1/traces/v1/traces` and routed
   logs and metrics through the trace URL. Resolution is now per signal: the
   signal-specific variable wins and is used as-is; the generic base gets
   the suffix. Headers follow the same per-signal pattern with a generic
   fallback.

2. `partialSuccess` was ignored. A 2xx with rejected records was recorded as
   full success. The response body is now parsed for rejected counts per
   signal and a partial rejection is recorded as a failure.

3. No shutdown flush. The runtime's documented "bounded 2s flush" was a
   comment with no implementation; buffered records were lost on every clean
   exit. The worker now selects on a shutdown signal, drains all three
   signal buffers, and the runtime awaits the join inside the 2s bound.
   Both binaries invoke it on every exit path.

4. Log timestamps were batch-level. Every record in a flush window got the
   same export-time timestamp, collapsing intra-batch ordering. Each record
   now carries its enqueue time.

5. The `mark_exited` heartbeat state was dead (nothing called it; a clean
   Drop deregisters) and `LimitRegistry::register` took the write lock
   twice; both cleaned up. Env-mutating config tests now serialize on a
   shared mutex so they cannot race on process-global `OTEL_*` variables.

Verified live against OpenObserve: per-signal URLs resolve exactly
(signal-specific as-is, generic suffixed), histograms export disjoint
bucket counts that conserve `count`, and spans/logs/metrics all flow.

Entire-Checkpoint: 01M0GYSD2YVNH359JTEEHD48H6
sample_host is a stub until the cgroup/process sampler lands, so
build_fleet_snapshot was emitting cpu_cores: 0.0 and memory_bytes: 0 — a
consumer of /api/v1/status could not distinguish an idle fleet from an
unmeasured one. VmHostUsage fields are now Option and skipped in JSON when
None.

Entire-Checkpoint: 01M0GZFAPGDSXX5JP9A2E0T5ZF
TaskSnapshot no longer carries exited — a clean Drop deregisters, so the
flag could only ever be false. The stale threshold stays a literal here;
it is consolidated into one constant in the follow-up review-fixes PR.

Entire-Checkpoint: 01M0GZG5SQ5X8W0GSW9T53DGR2
Jobs carry an enqueue timestamp so the claim path can measure true queue
latency instead of a hardcoded placeholder; the field is serde-defaulted so
snapshots persisted before the field existed restore as unknown and are
skipped. (The claim-path recording lands with the review fixes.)

Entire-Checkpoint: 01M0GZHJA9FSX6WRBH43JB9WFF
A known default password on a loopback port is one forwarded-port or one
other-local-user away from being public; compose now fails startup until
ZO_ROOT_USER_PASSWORD is supplied.

Entire-Checkpoint: 01M0GZJ67NNWNJNJ9KEYHPXT78
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 658b2da8-5d8a-4f58-8430-8da5e04e0e28

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@Bnjoroge1 Bnjoroge1 mentioned this pull request Aug 21, 2026
9 tasks
@@ -156,5 +159,8 @@ async fn main() -> anyhow::Result<()> {
.await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/main.rs:159

When serve(...).await? or a ? in the Cert arm returns an error, main exits before observability_runtime.shutdown() runs, dropping buffered telemetry instead of performing the advertised bounded drain. Capture the command result, await shutdown(), and return the captured result afterward.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-runner-server/src/main.rs around line 159:

When `serve(...).await?` or a `?` in the `Cert` arm returns an error, `main` exits before `observability_runtime.shutdown()` runs, dropping buffered telemetry instead of performing the advertised bounded drain. Capture the command result, await `shutdown()`, and return the captured result afterward.

// that via `install_fmt_subscriber`.
Self {
_handle: handle,
worker,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/lib.rs:614

On runner exit, the export worker is detached without draining its queue, so buffered telemetry from the final flush window is lost. ObservabilityRuntime::new stores the JoinHandle, but _observability_runtime is never used to call shutdown() and ObservabilityRuntime has no Drop implementation despite its documented drop behavior; ensure shutdown is awaited before the runner exits.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-observability/src/lib.rs around line 614:

On runner exit, the export worker is detached without draining its queue, so buffered telemetry from the final flush window is lost. `ObservabilityRuntime::new` stores the `JoinHandle`, but `_observability_runtime` is never used to call `shutdown()` and `ObservabilityRuntime` has no `Drop` implementation despite its documented drop behavior; ensure shutdown is awaited before the runner exits.

Comment on lines +107 to +115
let (version, trace_id, parent_span_id) = (parts[0], parts[1], parts[2]);
let valid = version.len() == 2
&& trace_id.len() == 32
&& parent_span_id.len() == 16
&& trace_id.chars().all(|c| c.is_ascii_hexdigit())
&& parent_span_id.chars().all(|c| c.is_ascii_hexdigit())
// All-zero ids are explicitly invalid per the spec.
&& trace_id.chars().any(|c| c != '0')
&& parent_span_id.chars().any(|c| c != '0');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/export.rs:107

Malformed traceparent headers are adopted instead of starting a new root, allowing invalid trace IDs to corrupt distributed-trace correlation. valid never checks parts[3], accepts forbidden version ff, and is_ascii_hexdigit() admits uppercase IDs; validate the version, flags, and lowercase-hex requirements before adoption.

-        let (version, trace_id, parent_span_id) = (parts[0], parts[1], parts[2]);
+        let (version, trace_id, parent_span_id, flags) = (parts[0], parts[1], parts[2], parts[3]);
         let valid = version.len() == 2
+            && version != "ff"
+            && version.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
             && trace_id.len() == 32
             && parent_span_id.len() == 16
-            && trace_id.chars().all(|c| c.is_ascii_hexdigit())
-            && parent_span_id.chars().all(|c| c.is_ascii_hexdigit())
+            && flags.len() == 2
+            && trace_id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
+            && parent_span_id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
+            && flags.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-observability/src/export.rs around lines 107-115:

Malformed `traceparent` headers are adopted instead of starting a new root, allowing invalid trace IDs to corrupt distributed-trace correlation. `valid` never checks `parts[3]`, accepts forbidden version `ff`, and `is_ascii_hexdigit()` admits uppercase IDs; validate the version, flags, and lowercase-hex requirements before adoption.

.ok()
.filter(|v| !v.trim().is_empty() && v.trim() != "none")
.map(|v| v.trim_end_matches('/').to_string())
.or_else(|| generic.as_ref().map(|g| format!("{g}{suffix}")))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/lib.rs:131

Generic endpoints with a query or fragment resolve to the wrong URL: https://collector/base?token=x becomes https://collector/base?token=x/v1/logs, so the request path remains /base instead of /base/v1/logs and exports fail or go to the wrong collector route. Append suffix to the URL path before its query/fragment, or strip those components if they are intentionally unsupported.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-observability/src/lib.rs around line 131:

Generic endpoints with a query or fragment resolve to the wrong URL: `https://collector/base?token=x` becomes `https://collector/base?token=x/v1/logs`, so the request path remains `/base` instead of `/base/v1/logs` and exports fail or go to the wrong collector route. Append `suffix` to the URL path before its query/fragment, or strip those components if they are intentionally unsupported.

v.pointer("/partialSuccess/rejectedLogRecords")
.or_else(|| v.pointer("/partialSuccess/rejectedSpans"))
.or_else(|| v.pointer("/partialSuccess/rejectedDataPoints"))
.and_then(|n| n.as_u64())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/export.rs:561

rejected_count_from_body returns None for normal OTLP protojson responses such as "rejectedSpans":"1", so post records a partially rejected batch as fully successful. The rejection fields are protobuf int64 values encoded as decimal strings; parse the string representation, while optionally retaining numeric input support.

Suggested change
.and_then(|n| n.as_u64())
.and_then(|n| n.as_u64().or_else(|| n.as_str().and_then(|s| s.parse::<u64>().ok())))
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-observability/src/export.rs around line 561:

`rejected_count_from_body` returns `None` for normal OTLP protojson responses such as `"rejectedSpans":"1"`, so `post` records a partially rejected batch as fully successful. The rejection fields are protobuf `int64` values encoded as decimal strings; parse the string representation, while optionally retaining numeric input support.

if k.is_empty() || v.is_empty() {
None
} else {
Some((k.to_string(), v.to_string()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/export.rs:618

parse_headers forwards percent-encoded header values unchanged, so Authorization=Basic%20abc reaches the OTLP exporter as Basic%20abc instead of Basic abc, causing authentication and other encoded header values to fail. Percent-decode each parsed value according to the W3C Baggage format before returning it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-observability/src/export.rs around line 618:

`parse_headers` forwards percent-encoded header values unchanged, so `Authorization=Basic%20abc` reaches the OTLP exporter as `Basic%20abc` instead of `Basic abc`, causing authentication and other encoded header values to fail. Percent-decode each parsed value according to the W3C Baggage format before returning it.

cpus: "1.0"
memory: 2G
healthcheck:
test: ["CMD", "sh", "-c", "wget -qO- http://127.0.0.1:5080/healthz || exit 1"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium openobserve/compose.yml:39

The openobserve container is permanently marked unhealthy because its distroless image contains neither sh nor wget, so the healthcheck fails before requesting /healthz. Replace this with an external probe-capable image or an executable present in the pinned image so depends_on: condition: service_healthy can work.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @contrib/openobserve/compose.yml around line 39:

The `openobserve` container is permanently marked unhealthy because its distroless image contains neither `sh` nor `wget`, so the healthcheck fails before requesting `/healthz`. Replace this with an external probe-capable image or an executable present in the pinned image so `depends_on: condition: service_healthy` can work.

std::env::var(var)
.ok()
.filter(|v| !v.trim().is_empty() && v.trim() != "none")
.map(|v| v.trim_end_matches('/').to_string())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/lib.rs:130

Signal-specific endpoints lose their trailing slash, so OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://collector/custom/ is resolved to https://collector/custom and requests are sent to a different route than configured. The shared resolve closure applies trim_end_matches('/') to signal-specific values; preserve those values as-is and only normalize the generic base before appending /v1/<signal>.

Suggested change
.map(|v| v.trim_end_matches('/').to_string())
.map(|v| v.to_string())
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-observability/src/lib.rs around line 130:

Signal-specific endpoints lose their trailing slash, so `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://collector/custom/` is resolved to `https://collector/custom` and requests are sent to a different route than configured. The shared `resolve` closure applies `trim_end_matches('/')` to signal-specific values; preserve those values as-is and only normalize the generic base before appending `/v1/<signal>`.

Comment on lines +126 to +132
let resolve = |var: &str, suffix: &str| {
std::env::var(var)
.ok()
.filter(|v| !v.trim().is_empty() && v.trim() != "none")
.map(|v| v.trim_end_matches('/').to_string())
.or_else(|| generic.as_ref().map(|g| format!("{g}{suffix}")))
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/lib.rs:126

A signal-specific "none" value still sends that signal to the generic OTLP endpoint when OTEL_EXPORTER_OTLP_ENDPOINT is set, so operators cannot disable individual signal export. The filter turns "none" into None, but or_else then falls back to generic; handle the explicit "none" case before applying the generic fallback.

-        let resolve = |var: &str, suffix: &str| {
-            std::env::var(var)
-                .ok()
-                .filter(|v| !v.trim().is_empty() && v.trim() != "none")
-                .map(|v| v.trim_end_matches('/').to_string())
-                .or_else(|| generic.as_ref().map(|g| format!("{g}{suffix}")))
-        };
+        let resolve = |var: &str, suffix: &str| {
+            match std::env::var(var) {
+                Ok(v) if v.trim() == "none" => None,
+                Ok(v) if !v.trim().is_empty() => {
+                    Some(v.trim_end_matches('/').to_string())
+                }
+                _ => generic.as_ref().map(|g| format!("{g}{suffix}")),
+            }
+        };
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-observability/src/lib.rs around lines 126-132:

A signal-specific `"none"` value still sends that signal to the generic OTLP endpoint when `OTEL_EXPORTER_OTLP_ENDPOINT` is set, so operators cannot disable individual signal export. The filter turns `"none"` into `None`, but `or_else` then falls back to `generic`; handle the explicit `"none"` case before applying the generic fallback.

start_nanos: u128,
health: &Arc<ExportHealth>,
) {
flush_logs(client, targets.logs.as_ref(), resource, logs, health).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/export.rs:354

The shutdown path drops every telemetry item still queued in rx, so accepted records are lost when request_shutdown wins the worker’s select!. drain_and_flush only flushes records already moved into logs and spans; drain rx into those buffers before calling this helper (or close and drain the receiver).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-observability/src/export.rs around line 354:

The shutdown path drops every telemetry item still queued in `rx`, so accepted records are lost when `request_shutdown` wins the worker’s `select!`. `drain_and_flush` only flushes records already moved into `logs` and `spans`; drain `rx` into those buffers before calling this helper (or close and drain the receiver).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant